Service와 Transaction
✒️ 2025-06-30 11:46 내용 수정
스프링부트3 자바 백엔드 개발입문 내용 참고 및 정리
Service
서버의 핵심 기능인 비즈니스 로직을 처리하는 순서를 총괄하는 계층
- 3 Tier Architecture#Business Tier에 속하는 계층으로, DAO(MyBatis) 또는 Repository(JPA)와 Controller 사이에 위치한다.
- 3 Tier Architecture#주문 추가 및 조회하는 페이지 만들기에서도 Spring의 실습 때와 달리 Service 인터페이스를 추가하여 공통적인 부분을 추상화시킨 뒤 나중에 각기 다른 세부 사항을 구현한 클래스들을 사용했다.
- DB -> Mapper -> DAO -> Service -> Controller 순서로 데이터를 가져와 Controller를 통해 View에 정보를 표시했다.
- JPA로 DB Read 수행하기 등의 실습에선 Controller에서 클라이언트의 요청을 받고, Repository가 DB로부터 데이터를 가져오거나 저장하는 일을 수행한 뒤, 클라이언트에 응답을 보내는 일을 수행했다.
- 여기서 Repository가 DB로부터 데이터를 가져오거나 저장하는 작업을 서비스로 분리해서 관리할 수 있다.
- 간단한 작업이라면 Controller 혼자서 여러 역할을 수행할 수 있으나, 실제 프로젝트나 웹 서비스에선 다양한 기능을 포함하기 때문에 Service 계층으로 분리시켜 관리하는 것이 좋다.
- Controller 내의 기능을 Service로 분리하여 Controller는 요청을 받고 그 결과를 클라이언트에 반환하는 역할을 하고, 주요 기능은 Service에서 처리한다.
REST API에 Service 계층 추가
- 실습 진행은 Spring boot로 REST API 구현(JPA)의 마지막 부분부터 진행하였다.
- 먼저
com.example.package_name패키지에service패키지를 추가하고ArticleService클래스를 생성한다.@ServiceAnnotation은 해당 클래스를 Service로 인식해 Service 객체를 생성한다.- Service 클래스 내에
ArticleRepository를 자동 주입하여 Repository를 통해 DB에 접근할 수 있도록 설정한다.
ArticleApiController에서 작성한 내용들 중 DB에 접속하는Repository동작 부분을ArticleService클래스의 메소드로 옮기고,ArticleApiController의 메소드에는ArticleService의 메소드 호출로 수정한다.- 서버의 상태 메시지를 보내는
ResponseEntity객체는ArticleApiController에서 생성해서 반환하고,ArticleService에선ArticleApiController에서 사용할 수 있는 반환값들을 가진 메소드로 설정한다.
- 서버의 상태 메시지를 보내는
package com.example.demo.api;
import com.example.demo.DTO.ArticleForm;
import com.example.demo.entity.Article;
import com.example.demo.service.ArticleService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
@RestController // REST를 적용한 Controller 사용 표시
@RequestMapping("/api")
@Slf4j // simple logging facade for java
public class ArticleApiController {
@Autowired
private ArticleService articleService;
// GET
@GetMapping("articles") // 게시글 전체 조회
public ArrayList<Article> index() {
return articleService.index(); // Service에서 DB 동작 수행
}
@GetMapping("articles/{id}") // 특정 게시글 조회
public Article show(@PathVariable Long id) {
return articleService.show(id);
}
// POST
@PostMapping("articles") // 새 글 작성
public ResponseEntity<Article> create(@RequestBody ArticleForm dto) {
// 요청 시 본문에 포함되는 데이터를 사용
Article created = articleService.create(dto);
// 추가하려는 데이터의 상태에 따라 서버 상태 코드를 다르게 반환
// null : 새 글을 추가해야 하는데 이미 id가 존재할 때(현재 자동 id 생성 적용된 상태)
return (created != null) ?
ResponseEntity.status(HttpStatus.OK).body(created) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// PATCH
@PatchMapping("articles/{id}") // 기존 글 수정
public ResponseEntity<Article> update( // 반환형에 유의한다.
@PathVariable Long id,
@RequestBody ArticleForm dto
) { // 요청 시 본문에 포함되는 데이터를 사용
Article updated = articleService.update(id, dto);
return (updated != null) ?
ResponseEntity.status(HttpStatus.OK).body(updated) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// DELETE
@DeleteMapping("articles/{id}")
public ResponseEntity<Article> delete(@PathVariable Long id) { // 글 삭제
Article deleted = articleService.delete(id);
return (deleted == null) ?
ResponseEntity.status(HttpStatus.OK).build() :
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
}
package com.example.demo.service;
import com.example.demo.DTO.ArticleForm;
import com.example.demo.entity.Article;
import com.example.demo.repository.ArticleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
@Service
public class ArticleService {
@Autowired
private ArticleRepository articleRepository;
public ArrayList<Article> index() { // 게시글 전체 조회
return (ArrayList<Article>) articleRepository.findAll();
}
public Article show(@PathVariable Long id) { // 특정 게시글 조회
return articleRepository.findById(id).orElse(null);
}
public Article create(ArticleForm dto) { // 새 글 작성
Article article = dto.toEntity();
// 새 글을 생성해야 하는데 이미 같은 id 데이터가 존재하면 안됨
// 이미 존재하는 id 데이터에 POST 요청을 넣으면 데이터를 수정하기에 REST API에 맞지 않음
if (article.getId() != null) {
return null;
}
return articleRepository.save(article);
}
public Article update(Long id, ArticleForm dto) { // 기존 글 수정
// 들어온 데이터 변환
Article article = dto.toEntity();
// 수정 대상 조회
Article target = articleRepository.findById(id).orElse(null);
// 잘못된 요청 처리
// 해당 id의 데이터가 없거나, 요청 id와 수정할 데이터의 id가 다른 경우
if (target == null || id != article.getId()) {
// 400 코드 전송
return null;
}
// DB에 수정 내용 저장
target.patch(article); // 기존 데이터에 새 데이터 붙이기(일부만 수정 시 null 방지)
Article updated = articleRepository.save(article);
// 200 코드와 수정 결과 전송
return updated;
}
public Article delete(Long id) { // 글 삭제
// 대상 조회
Article target = articleRepository.findById(id).orElse(null);
// 대상이 없으면 잘못된 요청으로 처리 - 400
if (target == null) {
return null;
}
// 대상이 있으면 삭제 진행
articleRepository.delete(target);
return target; // HTTP 응답의 body가 없는 ResponseEntity 생성
}
}
- Talend API Test를 사용하여 각 API 동작을 테스트하고, 서버 코드가 의도한대로 동작하지 않거나 에러가 발생하면
ArticleService와ArticleApiController의 기능 구현에 문제가 생기는지 확인한다.
Transaction
더 이상 나눌 수 없는 업무 처리의 최소 단위로, 모두 성공해야 하는 일련의 과정
- Service의 업무 처리는 Transaction 단위로 처리된다.
- Rollback : 과정 중간에 실패가 발생해 Transaction이 실패로 돌아가면 진행 초기 단계로 되돌리는 것이다.
- 트랜잭션(Transaction), Transaction 관리 상세 참고.
REST API에 Transaction 추가하기
- 교재에 있는 과정대로 article을 3개 생성 요청하고, 중간에 오류가 발생했을 때 롤백되는 과정을 확인한다.
ArticleApiController에@PostMapping()으로/api/transaction-test를 추가한다.- 요청에서 배열로 여러 개의 데이터를 받으므로
List<Article>로 데이터를 받아Service로 전달한다.
- 요청에서 배열로 여러 개의 데이터를 받으므로
package com.example.demo.api;
import com.example.demo.DTO.ArticleForm;
import com.example.demo.entity.Article;
import com.example.demo.service.ArticleService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@RestController // REST를 적용한 Controller 사용 표시
@RequestMapping("/api")
@Slf4j // simple logging facade for java
public class ArticleApiController {
@Autowired
private ArticleService articleService;
// ... 중략
// POST - Transaction
@PostMapping("test") // 새 글 작성 - 3개 연속 생성
public ResponseEntity<List<Article>> transactionTest(@RequestBody List<ArticleForm> dtos) {
List<Article> createdList = articleService.createArticles(dtos);
return (createdList != null) ?
ResponseEntity.status(HttpStatus.OK).body(createdList) :
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
}
// ... 중략
}
ArticleService에서도List<Article>을 생성하는 메소드를 추가하고, 강제 예외를 발생시켜 Transaction을 테스트한다.
package com.example.demo.service;
import com.example.demo.DTO.ArticleForm;
import com.example.demo.entity.Article;
import com.example.demo.repository.ArticleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ArticleService {
@Autowired
private ArticleRepository articleRepository;
// ...중략
public List<Article> createArticles(List<ArticleForm> dtos) { // 새 글 작성 - 3개 연속 생성
// stream을 사용해 List 내의 AricleForm을 모두 Article로 변환
List<Article> articleList = dtos.stream() // stream변환
.map(dto -> dto.toEntity()) // map 함수로 각 요소에 대해 함수 실행
.collect(Collectors.toList()); // Stream -> List로 변환
// Article 데이터들을 DB에 저장
articleList.stream() // stream 변환
.forEach(article -> articleRepository.save(article));
// forEach 함수로 각 요소에 대해 함수 실행
// transaction 테스트를 위해 강제 예외 발생
articleRepository.findById(-1L)
.orElseThrow(() -> new IllegalArgumentException("데이터 없음"));
return articleList;
}
// ...중략
}
- Talend API Tester에서 POST 요청으로
api/test에 JSON 데이터들을 배열로 작성한 후 요청을 보내면500응답이 오는데, 이는 내부 서버 에러가 발생한 것이다.ArticleService에서createArticles()내에 강제로 예외를 발생시켰기 때문에 응답 상태가500으로 생성되었다.
- 하지만 글 전체를 조회 시엔 글이 추가되었다는 것을 확인할 수 있다. 강제 예외 처리 이후엔 코드가 작동하면 안되지만 아직 Transaction 처리가 되지 않아 예외 이후 동작이 작동한 것이다.
- 이제 Transaction을 메소드에 적용하기 위해
ArticleService클래스의createArticles()메소드에@TransactionalAnnotation을 추가해 해당 메소드를 하나의 Transaction으로 묶는다.
package com.example.demo.service;
import com.example.demo.DTO.ArticleForm;
import com.example.demo.entity.Article;
import com.example.demo.repository.ArticleRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ArticleService {
@Autowired
private ArticleRepository articleRepository;
// ...중략
@Transactional
public List<Article> createArticles(List<ArticleForm> dtos) { // 새 글 작성 - 3개 연속 생성
// stream을 사용해 List 내의 AricleForm을 모두 Article로 변환
List<Article> articleList = dtos.stream() // stream변환
.map(dto -> dto.toEntity()) // map 함수로 각 요소에 대해 함수 실행
.collect(Collectors.toList()); // Stream -> List로 변환
// Article 데이터들을 DB에 저장
articleList.stream() // stream 변환
.forEach(article -> articleRepository.save(article));
// forEach 함수로 각 요소에 대해 함수 실행
// transaction 테스트를 위해 강제 예외 발생
articleRepository.findById(-1L)
.orElseThrow(() -> new IllegalArgumentException("데이터 없음"));
return articleList;
}
// ...중략
}
- 위의 POST 동작을 그대로 수행한 후 글 목록을 확인하면 이번엔 Transaction 처리로 인해 글이 추가되지 않은 것을 확인할 수 있다.
- 즉 메소드에서 예외 처리 이전으로 롤백되었다.